2|Draw Call

loading

2.1 Shader

Shader是运行在GPU上的一种处理图像信息的程序。要在哪里绘制,如何绘制通常由Shader决定。第一节我们也说到,SRP保留了对Unlit Shader的支持,从本节开始,我们将编写自己的Shader来渲染几何。

着色器有很多可编程的阶段,比如顶点着色器和片元着色器等。这些着色器的可编程性在于我们可以使用一种特定的语言来编写程序,就和我们可以用C#来编写游戏逻辑一样。着色语言就是专门用于编写着色器的,常见的三种高级着色语言分别是微软DirectX的HLSL(High Level Shading Language)、OpenGL的GLSL(OpenGL Shading Language)和NVIDIA的CG(C for Graphic)。这些语言会被编译成与机器无关的汇编语言,也被称为中间语言,这些中间语言再交给显卡驱动来翻译成真正的机器语言,即GPU可以理解的语言。

在Unity的内置渲染管线中,我们通常用CG语言来编写着色器,CG是真正意义上的跨平台,它会根据平台不同编译成相应的中间语言,且CG的跨平台很大原因取决于与微软的合作,也导致CG语言的语法和HLSL非常像,很多情况下CG语言甚至可以无缝移植成HLSL代码,但CG语言已经停止更新很多年了,基本上已经被放弃了。现在SRP的着色器代码库使用的是HLSL,Unity也使用了HLSL的编译器来编译Shader,且HLSL转GLSL比较容易。接下来的所有着色器代码我们将使用HLSL着色语言来编写。

在这里希望读者对Shader语法有一定的了解,一些基本的语法知识将不过多介绍。

2.1.1 Unlit Shader

本节内容不涉及光照的讲解,我们下面编写一个不受光照影响的Unlit Shader。

1. 新建一个Shaders子文件夹,用来存放着色器代码。我们创建一个Unlit Shader资源,删除里面所有默认代码。编写一个最基础的结构骨架:

Shader "CustomRP/Unlit"
{
Properties
{

}
SubShader
{

Pass
{

}
}
}

2. 创建一个材质球,命名为Unlit,使用该Shader。

loading

3. 我们接下来实现Pass块,因为我们使用HLSL着色语言,所以用HLSLPROGRAM和ENDHLSL来包裹Shader代码,只有这样才能正确的编译。开头也说到着色器有两个常用的可编程阶段,也就是顶点着色器和片元着色器。我们使用Pragma指令来标识顶点和片元着色器用什么函数来实现。Pragma个词来自希腊语,指的是一个行动或者一些需要做的事情,它在许多编程语言中用于发布特殊的编译器指令。

我们在Shader的同级目录下创建一个UnlitPass.hlsl文件,在HLSL文件中去实现这两个函数,这有利于代码的管理和组织重用。然后在Unlit.shader中使用#include指令插入HLSL的内容,文件的路径是相对路径。Unity没有直接创建HLSL的菜单选项,我们拷贝一个Unlit.shader,然后把“.shader”改为“.hlsl”,里面的代码全部清空。

   Pass
{
HLSLPROGRAM
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#include "UnlitPass.hlsl"
ENDHLSL
}

loading

4. 使用Include指令会插入该HLSL文件全部的内容,并且它允许多次Include同一文件,这样就有可能出现多份重复代码,导致重复声明或者其它一些编译报错,当编写的HLSL文件多了,一层嵌套一层,难免会出现重复Include同一文件的疏忽,所以我们有必要在编写HLSL着色器代码的时候加个保护。

我们一般通过#define指令定义一些标识符,在定义宏之前先判断一下是否定义过此标识符,如果定义过了,就跳过里面的所有代码不再执行,直接跳转到#endif末尾的代码。这样就能保证无论重复Include该HLSL文件多少次,只有第一次的Include是有效代码插入。这是一个良好的习惯,包括Unity的SRP源码库中的HLSL文件也是这么做的。

#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#endif

2.1.2 着色器函数

我们先定义一个空的顶点函数和片元函数来解决Shader的编译报错。然后在顶点函数中我们获取模型空间中的顶点位置并返回,模型空间中的顶点位置是一个三维向量,我们将其扩展到四维,W分量设为1。

为什么返回的是一个四维向量?

在计算机图形领域中,变换是一种非常重要的手段,它指的是把一些数据,如点、方向矢量和颜色等等通过某种方法进行转换的过程。常用的变换包括缩放、旋转和平移。其中缩放和旋转是线性变换,如果要对一个三维矢量进行变换,仅仅使用3X3的矩阵就可以表示所有的线性变换。但平移变换是非线性的,并不能用一个3X3的矩阵来表示,于是就有了仿射变换(Affine Transform)。仿射变换就是合并线性变换和平移变换的变换类型,可以使用一个4X4的矩阵来表示,为此我们需要把矢量扩展到四维空间下,这就是齐次坐标空间。

4X4的矩阵可以表示平移变换,同时我们要把三维的矢量转换成四维矢量,也就是齐次坐标。对于1个点,从三维坐标转换成齐次坐标是要把W分量设置为1,对于方向矢量则把W分量设置为0。这样的设置会导致:当用一个4X4矩阵对一个点进行变换时,平移、旋转和缩放都会施加于该点。但如果是用于变换一个方向矢量,平移的效果会被忽略。

#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
//顶点函数
float4 UnlitPassVertex(float3 positionOS : POSITION) : SV_POSITION
{
return float4(positionOS, 1.0);
}
//片元函数
void UnlitPassFragment() {}

#endif

然后我们创建一个球体,把Unlit材质球给它:

loading

Mesh被渲染出来了,但是效果不对,因为我们在顶点函数输出的顶点位置不是正确的空间。我们要进行空间变换,需要定义变换矩阵来进行。当物体被绘制时,这些变换矩阵会被发送到GPU,我们需要在Shader中添加这些矩阵,由于这些都是相同的且是通用的,我们将这些定义到一个单独的HLSL文件中方便代码管理和调用。

1. 新建一个ShaderLibrary子文件夹,来存放一些自定义的着色器库文件。我们在其下面新建一个UnityInput.hlsl文件,该文件用于存放Unity提供的一些的标准输入,我们在里面定义一个float 4X4类型的从模型空间到世界空间的转换矩阵。

//unity标准输入库
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED
//定义一个从模型空间转换到世界空间的转换矩阵
float4x4 unity_ObjectToWorld;

#endif

2. 空间转换矩阵有了,我们需要定义一个方法用来进行空间转换,这些函数基本都是常用的功能,我们在ShaderLibrary子文件夹中新建一个Common.hlsl库文件。然后定义一个将顶点从模型空间转换到世界空间的方法,传参是三维顶点坐标,并使用mul()方法完成顶点坐标的空间转换。第一个参数是转换矩阵,第二个是四维顶点坐标。我们返回世界空间的顶点坐标的XYZ分量。

//公共方法库
#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED
//使用UnityInput里面的转换矩阵前先include进来
#include "UnityInput.hlsl"
//函数功能:顶点从模型空间转换到世界空间
float3 TransformObjectToWorld(float3 positionOS)
{
return mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz;
}

#endif

3. 回到UnlitPass的顶点函数中,调用该方法进行空间转换。

#include "../ShaderLibrary/Common.hlsl"
//顶点函数
float4 UnlitPassVertex(float3 positionOS : POSITION) : SV_POSITION
{
float3 positionWS = TransformObjectToWorld(positionOS.xyz);
return float4(positionWS, 1.0);
}

4. 结果现在仍是错的,我们需要把顶点转换到齐次裁剪空间才能得到正确的结果。在UnityInput.hlsl中定义一个视图-投影转换矩阵,并在Common.hlsl定义方法将顶点从世界空间转换到齐次裁剪空间。

//定义一个从世界空间转换到裁剪空间的矩阵
float4x4 unity_MatrixVP;

//函数功能:顶点从世界空间转换到裁剪空间
float4 TransformWorldToHClip(float3 positionWS)
{
return mul(unity_MatrixVP, float4(positionWS, 1.0));
}

5. 在UnlitPass的顶点函数中将顶点转换到齐次裁剪空间后,我们得到了正确的结果。

float4 UnlitPassVertex (float3 positionOS : POSITION) : SV_POSITION 
{
float3 positionWS = TransformObjectToWorld(positionOS.xyz);
return TransformWorldToHClip(positionWS);
}

loading

2.1.3 SRP源码库

1. 以上提及的在Common.hlsl定义的两个空间转换方法比较常用,在安装的插件包Core RP Library中也有官方的库文件定义了这两个方法,所以我们把自己定义的TransformObjectToWorld和TransformWorldToHClip方法删除,把定义这两个方法的库文件SpaceTransforms.hlsl给Include进来。

//公共方法库
#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED
//使用UnityInput里面的字段前先include进来
#include "UnityInput.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
#endif

2. 这时会有编译报错,告诉你SpaceTransforms.hlsl中不存在unity_ObjectToWorld ,它希望我们定义UNITY_MATRIX_M宏来取代它,遵守它的规则才能通过编译。为了通过编译,我们定义一些宏来取代常用的转换矩阵。

//定义一些宏取代常用的转换矩阵
#define UNITY_MATRIX_M unity_ObjectToWorld
#define UNITY_MATRIX_I_M unity_WorldToObject
#define UNITY_MATRIX_V unity_MatrixV
#define UNITY_MATRIX_VP unity_MatrixVP
#define UNITY_MATRIX_P glstate_matrix_projection
#include
"Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

3. 然后在UnityInput.hlsl补充一些没有定义的转换矩阵。

//unity标准输入库
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
//这个矩阵包含一些在这里我们不需要的转换信息
real4 unity_WorldTransformParams;

float4x4 unity_MatrixVP;
float4x4 unity_MatrixV;
float4x4 glstate_matrix_projection;
#endif

4. 最后在Common.hlsl中把官方的Common库include进来,用来补全所有的别名替代宏。

#include 
"Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
//使用UnityInput里面的字段前先include进来
#include "UnityInput.hlsl"

5. 下面我们开始补全片元函数,我们想让它返回一个在材质面板中可调的颜色值。首先在Shader的属性块中定义一个Color值,供我们在材质面板中调色,然后在UnlitPass.hlsl定义对应的字段获取该颜色值,最后在片元函数中返回它。

  Properties
{
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
}

float4 _BaseColor;
//片元函数
float4 UnlitPassFragment() : SV_TARGET
{
return _BaseColor;
}

loading


2.2 批处理

2.2.1 Draw Call和Set Pass Call

要想CPU和GPU既可以并行又可以独立工作,要使用一个命令缓冲区(Command Buffer)。命令缓冲区包含了一个命令队列,当CPU需要渲染一些对象时,它会通过图像编程接口向命令缓冲区添加命令,当GPU完成上一次的渲染任务后,它会从命令队列中读取一个命令并执行它,添加和读取的过程是相互独立的。

命令缓冲区的命令有很多种类,而Draw Call就是其中一种,其它命令还有Set Pass Call等等。Set Pass Call代表了我们常说的改变渲染状态,当切换材质或者切换同一材质中Shader的不同Pass进行渲染时都会触发一次Set Pass Call。比如我们渲染1000个相同的物体和渲染1000个不同的物体,虽然两者Draw Call都是1000,但是前者Set Pass Call为1,后者还是1000。切换渲染状态往往比Draw Call更耗时,所以这也是URP不再支持多Pass的原因。

每次调用Draw Call之前,CPU都要向GPU发送很多内容,包括数据、状态和命令等。在这一阶段CPU需要完成很多工作,例如检查渲染状态等。一旦CPU完成了这些准备工作,GPU就可以开始本次渲染,GPU的渲染能力很强,渲染速度往往比CPU的提交命令速度快,如果Draw Call数量过多,CPU就会把大量时间花费在提交Draw Call上,造成CPU过载,游戏帧率变低,所以我们需要使用批处理(Batching)技术来降低Draw Call。

早期的Unity只支持动态批处理和静态批处理,后来又支持了GPU Instancing,最后SRP出现时支持了一种新的批处理方式SRP Batcher。本节内容我们不讨论静态批处理,其它三种批处理方式我们在渲染管线中会添加支持。

2.2.2 SRP Batcher

SRP Batcher是一种新的批处理方式,它不会减少Draw Call的数量,但可以减少Set Pass Call的数量,并减少绘制调用命令的开销。CPU不需要每帧都给GPU发送渲染数据,如果这些数据没有发生变化则会保存在GPU内存中,每个绘制调用仅需包含一个指向正确内存位置的偏移量。

SRP Batcher是否会被打断的判断依据是Shader变种,即使物体之间使用了不同的材质,但是使用的Shader变种相同就不会被打断,传统的批处理方式是要求使用同一材质为前提的。

SRP Batcher会在主存中将模型的坐标信息、材质信息、主光源阴影参数和非主光源阴影参数分别保存到不同的CBUFFER(常量缓冲区)中,只有CBUFFER发生变化才会重新提交到GPU并保存。

基本概念介绍完了,下面来实践。现在我们的Shader是不兼容SRP Batcher的,可以看到以下信息,我们需要对我们的Shader做一些调整。

loading

1. 它是指材质的所有属性都需要在常量内存缓冲区CBUFFER里定义,要我们将_BaseColor这个属性在名字为UnityPerMaterial的CBUFFER块中定义,如下所示。

cbuffer UnityPerMaterial 
{
float _BaseColor;
};

但并非所有平台(如OpenGL ES 2.0)都支持常量缓冲区,我们使用SRP源码库中的CBUFFER_START和CBUFFER_END宏来替代CBUFFER块。这样的话不支持常量缓冲区的平台就会忽略掉CBUFFER的代码。

2. 我们在UnlitPass.hlsl中将_BaseColor定义在名字为UnityPerMaterial的常量缓冲区中。

//所有材质的属性我们需要在常量缓冲区里定义
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
CBUFFER_END

3. 在UnityInput.hlsl中把几个矩阵定义在UnityPerDraw的常量缓冲区中。

CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;

real4 unity_WorldTransformParams;
CBUFFER_END

编译后发现还是Shader不兼容SRP Batcher:

loading

4. 它指出,如果我们需要使用一组特定值的其中一个值,我们需要把这组特定值全部定义出来,现在还缺少unity_LODFade的定义。

CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade;
real4 unity_WorldTransformParams;
CBUFFER_END

loading

5. 至此,我们的Shader已经兼容SRP Batcher了,我们在代码中启用SRP Batcher进行测试。创建渲染管线实例的时候,在构造函数里启用。

public class CustomRenderPipeline : RenderPipeline
{
CameraRenderer renderer = new CameraRenderer();
//测试SRP合批启用
public CustomRenderPipeline()
{
GraphicsSettings.useScriptableRenderPipelineBatching = true;
}

loading

可以看到,在Statistics面板中,有4个批次被存储起来,以负数的形式显示。在Frame Debugger中可以看到一个SRP Batch条目,但这不是说这些物体被合并成了一个Draw Call,而是指它们的优化序列。

2.2.3 多种颜色

若想给相同的物体设置不同的颜色,那么每个物体都需要使用一个不同的材质并调整颜色,我们接下来编写一个脚本,让所有相同物体使用同一个材质,但可以给每个物体设置不同的颜色。

在CustomRP下创建一个Examples子文件夹,新建脚本PerObjectMaterialProperties.cs,脚本中我们定义一个可以调整颜色的baseColor属性,并将颜色值通过MaterialPropertyBlock对象传递给材质。把这个脚本挂到每一个球体上面,然后设置不同的颜色。

[DisallowMultipleComponent]
public class PerObjectMaterialProperties : MonoBehaviour
{
static int baseColorId = Shader.PropertyToID("_BaseColor");

[SerializeField]
Color baseColor = Color.white;
static MaterialPropertyBlock block;

void OnValidate()
{
if (block == null)
{
block = new MaterialPropertyBlock();
}
//设置材质属性
block.SetColor(baseColorId, baseColor);

GetComponent<Renderer>().SetPropertyBlock(block);
}
void Awake()
{
OnValidate();
}
}

但我们发现SRP Batcher失效了,没有办法处理每个对象的材质属性。

loading

2.2.4 GPU Instancing

如果能将数据一次性发送给GPU,然后使用一个绘制函数让渲染流水线利用这些数据绘制多个相同的物体将会大大提升性能。这种技术就是GPU多例化(GPU Instancing)技术。使用GPU Instancing能够在一个绘制调用中渲染多个具有相同网格的物体,CPU收集每个物体的材质属性和变换,放入数组发送到GPU,GPU遍历数组按顺序进行渲染。

GPU多例化的思想,就是把每个实例的不同信息存储在缓冲区(可能是顶点缓冲区,可能是存储着色器Uniform变量的常量缓冲区)中, 然后直接操作缓冲区中的数据来设置。

假设需要渲染100个相同的模型,每个模型有256个三角形,那么需要两个缓冲区,一个是用来描述模型的顶点信息,因为待渲染的模型是相同的,所以这个缓冲区只存储了256个三角形(如果不存在任何的优化组织方式,则有768个顶点);另一个就是用来描述模型在世界坐标下的位置信息。例如不考虑旋转和缩放,100个模型即占用100个float3类型的存储空间。

以Direct3D 11为例,当准备好顶点数据、设置好顶点缓冲区之后,接下来进入输入组装阶段。输入组装阶段是使用硬件实现的。此阶段根据用户输入的顶点缓冲区信息、图元拓扑结构信息和描述顶点布局格式信息,把顶点组装成图元,然后发送给顶点缓冲区。设置好组装的相关设置后,对应的顶点着色器和片元着色器也要做好对应的设置才能使用多例化技术。

1. 要支持GPU Instancing,首先需要在Shader的Pass中添加#pragma multi_compile_instancing指令,然后在材质球上就能看到切换开关了,这时Unity会为我们的Shader生成两种变体。

    HLSLPROGRAM
#pragma multi_compile_instancing
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment

loading

2. 在Common.hlsl文件中include SpaceTransforms.hlsl之前,我们将SRP源码库中的UnityInstancing.hlsl文件Include进来,我们需要用到里面的一些定义好的宏和方法。

#include 
"Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"

#include
"Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

3. UnityInstancing.hlsl通过重新定义一些宏去访问实例的数据数组,它需要知道当前渲染对象的索引,该索引是通过顶点数据提供的。UnityInstancing.hlsl中定义了UNITY_VERTEX_INPUT_INSTANCE_ID宏来简化了这个过程,但它需要我们存在一个顶点输入结构体,我们定义它并将positionOS的定义放进来,然后在结构体中加入UNITY_VERTEX_INPUT_INSTANCE_ID宏,最后该结构体对象作为顶点函数的输入参数。然后在顶点函数添加UNITY_SETUP_INSTANCE_ID(input)代码,用来提取顶点输入结构体中的渲染对象的索引,并将其存储到其他实例宏所依赖的全局静态变量中。

//用作顶点函数的输入参数
struct Attributes
{
float3 positionOS : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

//顶点函数
float4 UnlitPassVertex(Attributes input) : SV_POSITION
{
UNITY_SETUP_INSTANCE_ID(input);
float3 positionWS = TransformObjectToWorld(input.positionOS);
return TransformWorldToHClip(positionWS);
}

4. 目前我们还不支持每个物体实例的材质数据,且SRP Batcher优先级比较高,我们还不能得到想要的结果。首先我们需要使用一个数组引用替换_BaseColor,并使用UNITY_INSTANCING_BUFFER_START和UNITY_INSTANCING_BUFFER_END替换CBUFFER_START和CBUFFER_END。

//CBUFFER_START(UnityPerMaterial)
// float4 _BaseColor;
//CBUFFER_END

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

5. 我们还需要在片元函数中也提供对象的索引,通过在顶点函数中使用UNITY_TRANSFER_INSTANCE_ID(input,output)将对象位置和索引输出,若索引存在则进行复制。为此我们还需定义一个片元函数输入结构体,在其中定义positionCS和UNITY_VERTEX_INPUT_INSTANCE_ID宏。

//用作片元函数的输入参数
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings UnlitPassVertex (Attributes input)
{
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
float3 positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(positionWS);
return output;
}

6. 在片元函数中也定义UNITY_SETUP_INSTANCE_ID(input)提供对象索引,且现在需要通过UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor)来访问获取材质的颜色属性了。

最后通过帧调试器可以看到4个小球已经合并成一个Draw Call了,它们使用的是同一材质。

loading

2.2.5 绘制许多网格小球

我们在Examples子文件夹下创建一个脚本MeshBall.cs来生成多个Mesh和多个小球对象,来展示成百上千个对象使用GPU Instancing进行合批的效果。

我们无需生成多个对象,只需要填充变换矩阵和颜色的数组,告诉GPU用它们去渲染Mesh,这样最多可以一次提供1023个实例,这是GPU Instancing的特性。然后我们在Awake方法中随机生成位置和颜色填充数组。最后调用Graphics.DrawMeshInstanced绘制网格。

public class MeshBall : MonoBehaviour
{
static int baseColorId = Shader.PropertyToID("_BaseColor");

[SerializeField]
Mesh mesh = default;
[SerializeField]
Material material = default;

Matrix4x4[] matrices = new Matrix4x4[1023];
Vector4[] baseColors = new Vector4[1023];


MaterialPropertyBlock block;

void Awake()
{
for (int i=0;i<matrices.Length;i++)
{
matrices[i] = Matrix4x4.TRS(Random.insideUnitSphere*10f,Quaternion.Euler(Random.value*360f, Random.value * 360f, Random.value * 360f),Vector3.one*Random.Range(0.5f,1.5f));
baseColors[i] = new Vector4(Random.value,Random.value,Random.value,Random.Range(0.5f,1f));
}
}

void Update()
{
if (block == null)
{
block = new MaterialPropertyBlock();
block.SetVectorArray(baseColorId, baseColors);
}
Graphics.DrawMeshInstanced(mesh,0,material,matrices,1023,block);
}
}

我们在场景中创建一个空的GameObject,然后挂上该脚本,设置球体Mesh和Unlit材质球,运行游戏即可。

loading

绘制1023个小球产生了3个Draw Call。每个Draw Call的最大缓冲区大小不一样,因此需要几个Draw Call是根据不同机器不同平台来决定的,单个网格的绘制顺序与我们提供数组数据的顺序相同。

2.2.6 动态合批

动态批处理的原理是每一帧把可以进行批处理的模型网格进行合并,再把合并好的数据传递给CPU,然后使用同一个材质进行渲染。好处是经过批处理的物体仍然可以移动,这是由于Unity每帧都会重新合并一次网格。

动态批处理有很多限制,比如在使用逐对象的材质属性时会失效,网格顶点属性规模要小于900等等,该技术适用于共享材质的小型的网格。

1. 我们的渲染管线已经支持了三种批处理,将这些批处理的启用开关设置成可配置项,使用或禁用哪种批处理由用户指定,在CameraRenderer.DrawVisibleGeometry()方法中作为参数传入。

void DrawVisibleGeometry(bool useDynamicBatching, bool useGPUInstancing)
{
//设置绘制顺序和指定渲染相机
var sortingSettings = new SortingSettings(camera)
{
criteria = SortingCriteria.CommonOpaque
};
//设置渲染的shader pass和渲染排序
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
{
//设置渲染时批处理的使用状态
enableDynamicBatching = useDynamicBatching,
enableInstancing = useGPUInstancing
};
...
}

2. 在Render方法中获取该配置的值。

public void Render(ScriptableRenderContext context, Camera camera,
bool useDynamicBatching, bool useGPUInstancing
)
{
...

Setup();
//绘制几何体
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
...
}

3. 在CustomRenderPipeline脚本中定义bool字段跟踪批处理的启用情况,在构造函数中获得这些配置值,最后传递到render.Render()方法中。

    bool useDynamicBatching, useGPUInstancing;
//测试SRP合批启用
public CustomRenderPipeline(bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher)
{
//设置合批启用状态
this.useDynamicBatching = useDynamicBatching;
this.useGPUInstancing = useGPUInstancing;
GraphicsSettings.useScriptableRenderPipelineBatching = useSRPBatcher;
}
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
foreach (Camera camera in cameras)
{
renderer.Render(context, camera, useDynamicBatching, useGPUInstancing);
}
}

4. 最后在CustomRenderPipelineAsset脚本中定义这三个可配置的批处理开关,实例化CustomRenderPipeline时作为参数传入。

//定义合批状态字段
[SerializeField]
bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;

//重写抽象方法,需要返回一个RenderPipeline实例对象
protected override RenderPipeline CreatePipeline()
{
return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher);
}

loading

我们禁用GPU Instancing和SRP Batcher,来测试动态批处理的效果。切换批处理的开关会立即生效,因为Unity在检测到管线资产改变时会创建一个新的渲染管线实例。(注:下图测试动态批处理时用的Cube进行测试,小球Mesh太大不满足动态合批的要求)。

loading


2.3 Alpha Blend和Alpha Test

透明是很常用的一种效果,在实时渲染中要实现透明效果,通常会在渲染模型时控制它的透明通道。在开启透明混合后,当一个物体被渲染到屏幕上,每个片元除了颜色值和深度值以外,还有一个透明度的属性:为1表示该像素是完全不透明的; 为0表示该像素完全不会显示。在Unity中我们通常使用两种方法来实现透明效果:第一种是透明度测试(Alpha Test),这种方法其实完全无法得到真正的半透明效果;另一种是透明度混合(Alpha Blend)。

渲染顺序问题也是很重要的。对于不透明物体,不考虑它们的渲染顺序也能得到正确的排序结果,这是由于强大的深度缓冲(Depth Buffer,也叫Z-buffer)的存在。在实时渲染中,深度缓冲是用于解决可见性问题的,它可以决定哪个物体的哪些部分会被渲染在前面,而哪些部分会被其它物体遮挡。它的基本思想是:根据深度缓存中的值来判断该片元距离摄像机的距离。当渲染一个片元时,需要把它的深度值和已经存在于深度缓冲中的值进行比较(如果开启了深度测试),如果它的值距离摄像机更远,那么说明这个片元不应该被渲染到屏幕上(有物体挡住了它);否则,这个片元应该覆盖掉此时颜色缓冲中的像素值,并把它的深度值更新到深度缓冲中(如果开启了深度写入)。


使用深度缓冲,可以让我们不用关心不透明物体的渲染顺序,例如A挡住B,即便我们先渲染A再渲染B,也不用担心B会遮盖掉A,因为在进行深度测试时会判断出B距离摄像机更远,也就不会写入到颜色缓冲中。如果想要实现透明效果,事情就不那么简单了,因为当使用透明度混合时,我们会关闭深度写入(ZWrite)。

透明度测试(Alpha Test)和透明度混合(Alpha Blend)的基本原理如下:

(1)透明度测试:采用极端霸道的方式,只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃。被舍弃的片元将不再进行任何处理,也不会对颜色缓冲产生影响,否则就按照普通不透明物体的处理方式来处理它,即进行深度测试,深度写入等。透明度测试是不需要关闭深度写入的,它和其它不透明物体最大的不同就是它会根据透明度来舍弃一些片元。虽然简单但产生的效果也很极端,要么完全透明要么完全不透明,而且它会使得硬件底层的优化技术Early-Z失效(将深度测试提前到片元着色器之前,减少片元计算量,减少overdraw)。

(2)透明度混合:这种方法可以得到真正的半透明效果,它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。需要注意的是,透明度混合只是关闭了深度写入,但没有关闭深度测试。这意味着当使用透明度混合渲染一个片元时,还是会比较它的深度值与当前深度缓冲的深度值,如果它的深度值距离摄像机更远,那么就不会再进行混合操作。这一点决定了,当一个不透明物体出现在一个透明物体的前面,即使我们先渲染了不透明物体,它仍然可以正常地遮挡住透明物体,对于透明度混合来说,深度缓冲是只读的。

目前我们的Unlit.shader只支持不透明材质,现在我们做些修改,让它可以切换成透明材质。

2.3.1 Blend Modes

为了进行混合,我们需要使用Unity的混合命令——Blend,这是Unity提供的设置混合模式的命令。想要实现半透明效果就需要把自身的颜色和已经存在于颜色缓冲中的颜色值进行混合,混合时使用的函数就是由该命令提供的。

Blend命令的语义有好几种,我们使用最常用的一种:Blend SrcFactor DstFactor。它的语义描述是:

开启混合,并设置混合因子,源颜色(该片元产生的颜色)会乘以SrcFactor,而目标颜色(已经存在于颜色缓冲的颜色)会乘以DstFactor,然后把两者相加后再存入颜色缓冲中。

示例:float4 result = SrcFactor * fragment_output + DstFactor * pixel_color。


下面给出ShaderLab支持的一些混合因子,对应参数的描述:


One:因子为1
Zero:因子为0
SrcColor:源颜色值
SrcAlpha:源颜色的透明通道的值
DstColor:目标颜色值
DstAlpha:目标颜色的透明通道的值
OneMinusSrcColor:1-源颜色值
OneMinusSrcAlpha:1-源颜色的透明通道的值
OneMinusDstColor:1-目标颜色值
OneMinusDstAlpha:1-目标颜色的透明通道的值
Blend operations:混合操作


通过混合操作和混合因子命令的组合,我们可以得到一些类似Photoshop混合模式中的混合效果,下图是一些常用的混合模式:

loading

1. 我们在Shader的属性中添加这两个混合因子,默认源混合因子是1,表示完全添加,目标混合因子是0,表示完全忽略,这是标准的不透明混合模式。

  Properties
{
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
//设置混合模式
[Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1
[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
}

loading

2. 标准透明物体混合模式源混合因子为SrcAlpha,所以混合等式为源颜色(该片元产生的颜色)的RGB乘上源颜色的Alpha值,目标混合因子为OneMinusSrcAlpha,代表颜色缓冲中的颜色值乘以(1-源颜色的Alpha值) 。我们在Pass中使用Blend语句来定义混合模式,并在材质面板中设置标准透明物体的源和目标混合因子。

Pass
{
//定义混合模式
Blend[_SrcBlend][_DstBlend]
HLSLPROGRAM

loading

3. 透明物体的渲染一般要关闭深度写入,不然得不到正确的结果。我们在属性栏中定义是否写入深度的属性,然后在Pass中通过ZWrite语句控制是否写入深度缓冲。最后我们可以调整下透明物体的渲染队列为3000,让其在不透明物体和天空盒之后渲染。

  //设置混合模式
[Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend("Src Blend", Float) = 1
[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend("Dst Blend", Float) = 0
//默认写入深度缓冲区
[Enum(Off, 0, On, 1)] _ZWrite("Z Write", Float) = 1

  Pass
{
//定义混合模式
Blend[_SrcBlend][_DstBlend]
//是否写入深度
ZWrite[_ZWrite]

loading

2.3.2 材质添加对纹理的支持

1. 我们的材质球目前还不支持使用纹理,现在添加这个功能,在属性栏声明一张纹理。

Properties
{
_BaseMap("Texture", 2D) = "white" {}

2. Unity会自动将使用的纹理上传到GPU内存中,然后使用TEXTURE2D()宏定义一张2D纹理,并使用SAMPLER(sampler+纹理名)这个宏为该纹理指定一个采样器。纹理和采样器是着色器资源,必须在全局定义,不能放入缓冲区中。除此之外还需要获取纹理的平铺和偏移值,这是通过定义一个float4类型的纹理名_ST属性来获取的,该属性可以在UnityPerMaterial缓冲区中定义,设置给每个对象实例。

TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
//提供纹理的缩放和平移
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

3. 要采样纹理,我们还需要一套UV坐标,它应该被定义在顶点输入结构体中,纹理坐标要传到片元函数中进行采样,所以片元输入结构体也要定义UV坐标。

//用作顶点函数的输入参数
struct Attributes
{
float3 positionOS : POSITION;
float2 baseUV : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
//用作片元函数的输入参数
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

4. 在顶点函数中,传递纹理坐标之前把为纹理的缩放和偏移也计算在内。

//顶点函数
Varyings UnlitPassVertex(Attributes input)
{
...
//计算缩放和偏移后的UV坐标
float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
output.baseUV = input.baseUV * baseST.xy + baseST.zw;
return output;
}

5. 最后我们将片元函数通过SAMPLE_TEXTURE2D宏对纹理采样,采样结果和颜色值相乘得到最终表面颜色。

//片元函数
float4 UnlitPassFragment (Varyings input) : SV_TARGET
{
UNITY_SETUP_INSTANCE_ID(input);
float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
// 通过UNITY_ACCESS_INSTANCED_PROP访问material属性
float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
return baseMap * baseColor;
}

loading

我们当前使用的纹理RGB颜色值为白色,但Alpha不同,所以颜色不受影响,但透明度每个物体各有不同。

2.3.3 透明度测试(Alpha Test)

1. 本节开头我们已经讲过了透明度测试的原理,只要一个片元的透明度不满足条件(通常是小于某个阈值),那么它对应的片元就会被舍弃,首先在属性栏中添加一个_Cutoff属性作为舍弃像素的阈值。

  Properties
{
_BaseMap("Texture", 2D) = "white" {}
_BaseColor("Color", Color) = (1.0, 1.0, 1.0, 1.0)
//透明度测试的阈值
_Cutoff("Alpha Cutoff", Range(0.0, 1.0)) = 0.5

2. 在UnityPerMaterial缓冲区中定义该属性,随后在片元函数中使用clip()函数舍弃不满足阈值的片元,它会判断传参如果为负数,就会舍弃当前像素的输出颜色(该片元就会产生完全透明的效果)。

UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)

float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
float4 base = baseMap * baseColor;
//透明度低于阈值的片元进行舍弃
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
return base;

loading

材质通常使用透明度测试和透明度混合其中一个,而不是同时使用。透明度测试应使用在完全不透明的物体身上,除了被clip丢弃的片元外,其它片元会写入深度缓冲中。我们把混合模式设置成标准不透明物体的配置,然后开启深度写入,渲染队列设置为AlphaTest。

loading

2.3.4 Shader Feature

使用shader feature可以让Unity根据不同的定义条件或关键字编译多次,生成多个着色器变体。然后通过外部代码或者材质面板上的开关来启用某个关键字,加载对应的着色器变种版本来执行某些特定功能,是项目开发中比较常用的一种手段。下面我们的目标是添加一个控制透明度测试功能是否启用的开关。

1. 首先添加一个控制着色器关键字的Toggle切换开关来控制是否启用透明度测试功能。

  //透明度测试的阈值
_Cutoff("Alpha Cutoff", Range(0.0, 1.0)) = 0.5
[Toggle(_CLIPPING)] _Clipping("Alpha Clipping", Float) = 0

2. 在Pass中使用shader feature声明一个Toggle开关对应的_CLIPPING关键字。

  HLSLPROGRAM
#pragma shader_feature _CLIPPING
#pragma multi_compile_instancing

3. 然后在片元函数中通过判断该关键字是否被定义,来控制是否进行裁剪操作。

#if defined(_CLIPPING)
//透明度低于阈值的片元进行舍弃
clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endif
return base;

接下来就可以在材质面板中将控制裁剪功能启用。

loading

2.3.5 逐对象的裁剪

1. 我们在PerObjectMaterialProperties.cs脚本中也添加裁剪的属性,可以给每个对象设置不同的裁剪程度,和设置颜色属性时差不多。

static int baseColorId = Shader.PropertyToID("_BaseColor");
static int cutoffId = Shader.PropertyToID("_Cutoff");

static MaterialPropertyBlock block;

[SerializeField]
Color baseColor = Color.white;

[SerializeField, Range(0f, 1f)]
float cutoff = 0.5f;



void OnValidate ()
{

block.SetColor(baseColorId, baseColor);
block.SetFloat(cutoffId, cutoff);
GetComponent<Renderer>().SetPropertyBlock(block);
}

2. 在MeshBall.cs脚本绘制多个小球时也进行一些调整。首先我们让每个小球的旋转角度增加一个随机变化,我们不设置每个小球的裁剪值,而是让每个小球的Alpha值在[0.5,1]区间内进行随机,最后可以通过调整材质上的裁剪值来控制小球的裁剪。

   matrices[i] = Matrix4x4.TRS(Random.insideUnitSphere*10f, Quaternion.Euler
(
Random.value * 360f, Random.value * 360f, Random.value * 360f
),
Vector3.one * Random.Range(0.5f, 1.5f));
baseColors[i] = new Vector4(Random.value,Random.value,Random.value,Random.Range(0.5f, 1f));

loading

源代码及PDF课件地址:

文件下载
共2条评论发表评论
suppertbw2021-05-25 22:37:21
我查了很久,那个alpha test功能如果贴图给自带的Default-ParticleSystem是有clip效果的,然而我从网上下张贴图放进去就不行了,clip功能失效!
suppertbw2021-05-25 22:07:35
无法解释的现象,alpha test那边不工作,看不到被clip掉的效果;alpha blend是好的